// ==UserScript==
// @name 笔趣阁优化 for iOS
// @namespace Violentmonkey Scripts
// @match https://lingjingxingzhe.com/*.html
// @match https://m.qishuta.org/*.html
// @exclude-match https://*/*index*.html
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @require https://cdn.jsdelivr.net/npm/alpinejs@3.13.0/dist/cdn.min.js
// @version 2.0.0
// @author LinHQ
// @license GPLv3
// @description 极简的 iOS Safari 端辅助小说阅读脚本
// ==/UserScript==
(async () => {
// 自定义网站配置
let sites = [
{
host: 'lingjingxingzhe.com',
title: 'h1.title',
content: '#content',
filters: ['.header', '.nav', 'body>:not(.container,.m-setting,#ird-main)'],
banRegex: [/打开站内搜索即可阅读/g, /\s?[||]\s?/g]
},
{
host: 'm.qishuta.org',
title: '#chaptertitle',
content: '#novelcontent',
filters: ['.msitetext', 'body > :not(.main,.ird-main)', '#strl', 'script+div', '#novelcontent .novelbutton', '#novelcontent :not(br,hr,h3)', 'body style+div'],
banRegex: [/[((]本章.+?继续阅读[))]/g, /第.+?(
){2}/g, /(
){2}.+?继续阅读[))]/g, /百度.+?即可阅读/g, /搜.+?最新章节/g, /全网首发/g, /新阅读.+?网站/g, /第.章.+?[))]/g]
}
]
const appTemplate = `
`
function App() {
return {
loading: false,
loaded: [],
// 注意,如果改用 petite-vue 则不支持写在里面,只能挪到外面去
pointers: new Set(),
nextObserver: null,
hideApp: false,
config: {
showTOC: false,
hideToolbar: false,
fontSize: 18,
theme: 'light',
version: '2.0.0',
htmlMode: false,
readingURL: document.URL
},
// 获取阅读信息存储的 key
get storeKey() {
const path = (new URL(document.URL)).pathname.split('/')
path.pop()
return path.pop()
},
async init() {
// 先获取保存的配置
const saved = await GM.getValue(this.storeKey)
if (saved && this.config.version === saved.version) {
if (saved.readingURL && saved.readingURL !== this.config.readingURL) {
if (confirm('是否跳转到上次阅读章节?')) {
document.location = saved.readingURL
}
}
Object.assign(this.config, saved)
this.config.readingURL = document.URL
}
let doc = this.parseDoc(document.body)
this.loaded.push({key: new URL(document.URL).pathname, doc})
this.nextObserver = new IntersectionObserver(this.handleNext.bind(this), {
// 进入可见区域之前进行检测
root: this.$refs.contentWrapper,
rootMargin: '100%'//getComputedStyle(this.$refs.contentWrapper).height
})
// 在初始化元素变更完之后再开始各种监听
await this.switchObserver(true)
this.$watch('config', async () => {
await GM.setValue(this.storeKey, this.config)
})
},
async switchObserver(start) {
if (start) {
await this.$nextTick()
this.nextObserver.observe(this.$refs.segment)
} else {
this.nextObserver.disconnect()
}
},
// 修正 重排模式 下的各种显示问题
fixFormat(text) {
let result = text.replaceAll(/[^\S\n]{2}/g, ' ')
return result
},
// 解析 doc 到 object
parseDoc(doc) {
const result = {}, previous = this.loaded.length > 0 ? this.loaded[this.loaded.length - 1] : null
const currentSite = sites.find(site => document.URL.includes(site.host))
// 解析链接
for (const a of doc.querySelectorAll('a')) {
if (a.textContent.includes('上一'))
result.prev = a.href
else if (a.textContent.includes('下一'))
result.next = a.href
else if (a.textContent.includes('目录')) {
result.toc = a.href
}
}
// 标题取得
result.currentTitle = doc.querySelector(currentSite.title)?.textContent
// 内容取得
const contentEle = doc.querySelector(currentSite.content)
currentSite.filters.forEach(reg => {
contentEle.querySelectorAll(reg).forEach(ele => ele.remove())
})
let html = contentEle.innerHTML
let text = contentEle.textContent
currentSite.banRegex.forEach(reg => {
text = text.replaceAll(reg, '')
html = html.replaceAll(reg, '')
})
result.html = html.replaceAll(/( ){2,}/g, ' ')
result.text = this.fixFormat(text)
// 没有 previous 就是第一个直接解析的页面
result.showTitle = previous ? result.currentTitle !== previous.doc.currentTitle : true
// 虽然说 key 就是这个,但是加上也没啥
result.URL = previous?.doc.next ?? document.URL
return result
},
handleTap(e) {
// 提高多点下效率
if (this.pointers.size === 0) return
const container = this.$refs.contentWrapper
const viewHeight = parseInt(getComputedStyle(container).height),
viewWidth = parseInt(getComputedStyle(container).width)
const pageLength = viewHeight - this.config.fontSize * 1.5
switch (this.pointers.size) {
case 1:
// 不妨直接获取倒数第二块的rect计算滚动距离
if (e.clientX < viewWidth / 3) {
// window.scrollBy(0, -1 * viewHeight - fontSize * lineHeight * 1.5)
container.scrollBy(0, pageLength)
} else if (e.clientX > viewWidth * 3 / 4) {
container.scrollBy(0, pageLength)
} else {}
break
case 3:
this.config.hideToolbar = !this.config.hideToolbar
break
}
this.pointers.clear()
},
toggleTheme() {
this.config.theme = this.config.theme === 'light' ? 'dark' : 'light'
},
async handleNext(entries) {
if (!entries[0].isIntersecting) {
return
}
await this.switchObserver(false)
// 下面开始执行无限加载
try {
const {doc: now} = this.loaded[this.loaded.length - 1]
const newDoc = await this.fetchDocument(now.next)
const newParsedDoc = this.parseDoc(newDoc)
this.loaded.push({key: now.next, doc: newParsedDoc})
// 新加载的可能还没滚到,所以还是保存上一章的 URL
this.config.readingURL = now.URL
} catch (e) {
console.error(e)
} finally {
await this.switchObserver(true)
}
},
// html 直拼还是 textContent 修正
async toggleParseMode() {
await this.switchObserver(false)
this.config.htmlMode = !this.config.htmlMode
await this.switchObserver(true)
},
async navigate(action) {
await this.switchObserver(false)
const current = this.loaded[this.loaded.length > 2 ? this.loaded.length - 2 : 0].doc
const cached = this.loaded.find(rec => rec.key === current[action])
// 切换之后无论如何显示标题
if (cached) {
cached.doc.showTitle = true
this.loaded = [cached]
} else {
const page = await this.fetchDocument(current[action])
const doc = this.parseDoc(page)
doc.showTitle = true
this.loaded = [{key: current[action], doc}]
}
this.config.readingURL = current[action]
await this.switchObserver(true)
},
toToc() {
this.hideApp = true
this.config.readingURL = undefined
document.location = this.loaded[0].doc.toc
},
async fetchDocument(url) {
this.loading = true
const page = await fetch(url).then(resp => resp.arrayBuffer()),
decoder = new TextDecoder(document.characterSet)
this.loading = false
return new DOMParser().parseFromString(decoder.decode(page), 'text/html')
}
}
}
// 用 WebComponent
class IReader extends HTMLElement {
constructor() {
super()
this.root = this.attachShadow({mode: 'closed'})
this.root.innerHTML = appTemplate
}
connectedCallback() {
Alpine.data('App', App)
// closed shadowRoot 无法通过遍历获取,需要传给 Alpine
Alpine.initTree(this.root)
}
}
customElements.define('i-reader', IReader)
const ird = document.createElement('i-reader')
ird.id = 'ird-main'
ird.className = 'ird-main show'
document.body.appendChild(ird)
})()